import {loadCssFile, loadScript} from 'app/client/lib/loadScript';
import {makeT} from 'app/client/lib/localization';
import type {AppModel} from 'app/client/models/AppModel';
import {urlState} from 'app/client/models/gristUrlState';
import {reportError} from 'app/client/models/errors';
import {createAppPage} from 'app/client/ui/createAppPage';
import {invokePrompt} from 'app/client/ui2018/modals';
import {DocAPIImpl} from 'app/common/UserAPI';
import type {RecordWithStringId} from 'app/plugin/DocApiTypes';
import {dom, styled} from 'grainjs';
import type SwaggerUI from 'swagger-ui';

const t = makeT('apiconsole');

/**
 * This loads the swagger resources as if included as separate <script> and <link> tags in <head>.
 *
 * Swagger suggests building via webpack (in which case, it would be included into our JS bundle),
 * but I couldn't get past webpack errors (also it's unclear if that would be any better).
 * We load dynamically only to avoid maintaining a separate html file ust for these tags.
 */
function loadExternal() {
  return Promise.all([
    loadScript('swagger-ui-bundle.js'),
    loadCssFile('swagger-ui.css'),
    // Stylesheet that's only applied when prefers-color-scheme is dark.
    loadCssFile('swagger-ui-dark.css'),
  ]);
}

// Start loading scripts early (before waiting for AppModel to get initialized).
const externalScriptsPromise = loadExternal();

let swaggerUI: SwaggerUI|null = null;

// Define a few types to allow for type-checking.

type ParamValue = string|number|null;

interface Example {
  value: ParamValue;
  summary: string;
}

interface JsonSpec {
  [propName: string]: any;
}
interface SpecActions {
  changeParamByIdentity(...args: unknown[]): unknown;
  updateJsonSpec(spec: JsonSpec): unknown;
}


function applySpecActions(cb: (specActions: SpecActions, jsonSpec: JsonSpec) => void) {
  // Don't call actions directly within `wrapActions`, react/redux doesn't like it.
  setTimeout(() => {
    // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
    const system = (swaggerUI as any).getSystem();
    const jsonSpec = system.getState().getIn(["spec", "json"]);
    cb(system.specActions, jsonSpec);
  }, 0);
}

function updateSpec(cb: (spec: JsonSpec) => JsonSpec) {
  applySpecActions((specActions: SpecActions, jsonSpec: JsonSpec) => {
    // `jsonSpec` is a special immutable object with methods like `getIn/setIn`.
    // `updateJsonSpec` expects a plain JS object, so we need to convert it.
    specActions.updateJsonSpec(cb(jsonSpec).toJSON());
  });
}

const searchParams = new URL(location.href).searchParams;

function setExamples(examplesArr: Example[], paramName: string) {
  examplesArr.sort((a, b) => String(a.summary || a.value).localeCompare(String(b.summary || b.value)));

  const paramValue = searchParams.get(paramName);
  let haveCurrentValue = false;
  if (paramValue) {
    // If this value appears among examples, move it to the front and label it as "Current".
    const index = examplesArr.findIndex(v => (String(v.value) == String(paramValue)));
    if (index >= 0) {
      const ex = examplesArr.splice(index, 1)[0];
      ex.summary += " (Current)";
      examplesArr.unshift(ex);
      haveCurrentValue = true;
    }
  }
  if (!haveCurrentValue) {
    // When opening an endpoint, parameters with examples are immediately set to the first example.
    // For documents and tables, this would immediately call our custom code,
    // fetching lists of tables/columns. This is especially bad for documents,
    // as the document may have to be loaded from scratch in the doc worker.
    // So the dropdown has to start with an empty value in those cases.
    // You'd think this would run into the check for `!value` in `changeParamByIdentity`,
    // but apparently swagger has its own special handing for empty values before then.
    examplesArr.unshift({value: "", summary: "Select..."});
  }

  // Swagger expects `examples` to be an object, not an array.
  // Prefix keys with something to ensure they aren't viewed as numbers: JS objects will iterate
  // them in insertion (what we want) order *unless* keys look numeric. SwaggerUI will use the
  // value from ex.value, so luckily this prefix doesn't actually matter.
  const examples = Object.fromEntries(examplesArr.map((ex) => ["#" + ex.value, ex]));
  updateSpec(spec => {
    return spec.setIn(["components", "parameters", `${paramName}PathParam`, "examples"], examples);
  });
}

// Set the value of a parameter in all endpoints.
function setParamValue(resolvedParam: any, value: ParamValue) {
  applySpecActions((specActions: SpecActions, spec: JsonSpec) => {
    // This will be something like:
    // "https://url-to-grist.yml#/components/parameters/orgIdPathParam"
    // Note that we're assuming that the endpoint always uses `$ref` to define the parameter,
    // rather than defining it inline.
    // https://github.com/gristlabs/grist-help/pull/293 ensures this,
    // but future changes to the spec must remember to do the same.
    const ref = resolvedParam.get("$$ref");

    // For every endpoint in the spec...
    for (const [pathKey, path] of spec.get("paths").entries()) {
      for (const [method, operation] of path.entries()) {
        // Skip the $ref for now, it is only used in `scim` endpoints which don't share
        // parameters with other endpoints.
        if (method === '$ref') {
          continue;
        }
        const parameters = operation.get("parameters");
        if (!parameters) { continue; }
        for (const param of parameters.values()) {
          // If this is the same parameter...
          if (ref.endsWith(param.get("$ref"))) {
            // Set the value. The final `true` is `noWrap` to prevent infinite recursion.
            specActions.changeParamByIdentity([pathKey, method], resolvedParam, value, false, true);
          }
        }
      }
    }
  });
}

class ExtendedDocAPIImpl extends DocAPIImpl {
  public listTables(): Promise<{tables: RecordWithStringId[]}> {
    return this.requestJson(`${this.getBaseUrl()}/tables`);
  }
  public listColumns(tableId: string, includeHidden = false): Promise<{columns: RecordWithStringId[]}> {
    return this.requestJson(`${this.getBaseUrl()}/tables/${tableId}/columns?hidden=${includeHidden ? 1 : 0}`);
  }
}

function wrapChangeParamByIdentity(appModel: AppModel, system: any, oriAction: any, ...args: any[]) {
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  const [keyPath, param, value, _isXml, noWrap] = args;
  if (noWrap || !value) {
    // `noWrap` is our own flag to avoid infinite recursion.
    // It's set when calling this action inside `setParamValue` below.
    // `value` is falsy when choosing our default "Select..." option from a dropdown.
    return oriAction(...args);
  }

  const paramName = param.get("name");

  // These are the path parameters that we handle specially and provide examples for.
  // When a value is selected in one endpoint, set the same value in all other endpoints.
  // This makes a bit more convenient to do multiple different operations on the same object.
  // But maybe it'll cause confusion/mistakes when operating on different objects?
  if (["orgId", "workspaceId", "docId", "tableId", "colId"].includes(paramName)) {
    setParamValue(param, value);
  }

  // When a docId is selected, fetch the list of that doc's tables and set examples for tableId.
  // This is a significant convenience, but it causes some UI jankiness.
  // Updating the spec with these examples takes some CPU and the UI freezes for a moment.
  // Then things jump around a bit as stuff is re-rendered, although it ends up in the right place
  // so it shouldn't be too disruptive.
  // All this happens after a short delay while the tables are being fetched.
  // It *might* be possible to set these example values more efficiently/lazily but I'm not sure,
  // and it'll probably significantly more difficult.
  const baseUrl = appModel.api.getBaseUrl();
  if (paramName === "docId") {
    const docAPI = new ExtendedDocAPIImpl(baseUrl, value);
    docAPI.listTables().then(({tables}: {tables: RecordWithStringId[]}) => {
      const examples: Example[] = tables.map(table => ({value: table.id, summary: table.id}));
      setExamples(examples, "tableId");
    })
    .catch(reportError);
  }

  // When a tableId is selected, fetch the list of columns and set examples for colId.
  // This causes similar UI jankiness as above, but I think less severely since fewer endpoints
  // have a colId parameter. In fact, there's currently only one: `DELETE /columns`.
  // We *could* only do this when setting tableId within that endpoint,
  // but then the dropdown will be missing if you set the tableId elsewhere and then open this endpoint.
  // Alternatively, `GET /tables` could be modified to return column metadata for each table.
  if (paramName === "tableId") {
    // When getting tables after setting docId, `value` is the docId so we have all the info.
    // Here `value` is the tableId and we need to get the docId separately.
    const parameters = system.getState().getIn(["spec", "meta", "paths", ...keyPath, "parameters"]);
    const docId = parameters.find((_value: any, key: any) => key.startsWith("path.docId"))?.get("value");
    if (docId) {
      const docAPI = new ExtendedDocAPIImpl(baseUrl, docId);
      // Second argument of `true` includes hidden columns like gristHelper_Display and manualSort.
      docAPI.listColumns(value, true)
      .then(({columns}: {columns: RecordWithStringId[]}) => {
        const examples = columns.map(col => ({value: col.id, summary: col.fields.label as string}));
        setExamples(examples, "colId");
      })
      .catch(reportError);
    }
  }
  return oriAction(...args);
}

function gristPlugin(appModel: AppModel, system: any) {
  return {
    statePlugins: {
      spec: {
        wrapActions: {
          // Customize what happens when a parameter is changed, e.g. selected from a dropdown.
          changeParamByIdentity: (oriAction: any) => (...args: any[]) =>
            wrapChangeParamByIdentity(appModel, system, oriAction, ...args),
        }
      }
    }
  };
}

function initialize(appModel: AppModel) {
  // These are used to set the examples for orgs, workspaces, and docs.
  const orgsPromise = appModel.api.getOrgs();

  // We make a request for each org - hopefully there aren't too many.
  // Currently I only see rate limiting in DocApi, which shouldn't be a problem here.
  // Fortunately we don't need a request for each workspace,
  // since listing workspaces in an org also lists the docs in each workspace.
  const workspacesPromise = orgsPromise.then(orgs => Promise.all(orgs.map(org =>
    appModel.api.getOrgWorkspaces(org.id, false).then(workspaces => ({org, workspaces}))
  )));

  // To be called after the spec is downloaded and parsed.
  function onComplete() {
    // Add an instruction for where to get API key.
    const description = document.querySelector('.information-container .info');
    if (description) {
      const href = urlState().makeUrl({account: 'account'});
      dom.update(description, dom('div', 'Find or create your API key at ', dom('a', {href}, href), '.'));
    }

    updateSpec(spec => {
      // The actual spec sets the server to `https://{subdomain}.getgrist.com/api`,
      // where {subdomain} is a variable that defaults to `docs`.
      // We want to use the same server as the page is loaded from.
      // This simplifies the UI and makes it work e.g. on localhost.
      spec = spec.set("servers", [{url: window.origin + "/api"}]);

      // Some table-specific parameters have examples with fake data in grist.yml. We don't want
      // to actually use this for running requests, so clear those out.
      for (const paramName of [
        'filterQueryParam', 'sortQueryParam', 'sortHeaderParam',
        'limitQueryParam', 'limitHeaderParam'
      ]) {
        spec = spec.removeIn(["components", "parameters", paramName, "example"]);
      }
      return spec;
    });

    // Show that we need a key, but let's not display it. The user may or may not have the API key
    // set. Actual requests from the console use cookies, so can work anyway. When the key is set,
    // showing it in cleartext makes it riskier to ask for help with screenshots and the like.
    // We set a fake key anyway to be clear that it's needed in the curl command.
    const key = 'XXXXXXXXXXX';
    // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
    swaggerUI!.preauthorizeApiKey('ApiKey', key);

    // Set examples for orgs, workspaces, and docs.
    orgsPromise.then(orgs => {
      const examples: Example[] = orgs.map(org => ({
        value: org.domain,
        summary: org.name,
      }));
      setExamples(examples, "orgId");
    }).catch(reportError);

    workspacesPromise.then(orgs => {
      const workSpaceExamples: Example[] = orgs.flatMap(({org, workspaces}) => workspaces.map(ws => ({
        value: ws.id,
        summary: `${org.name} » ${ws.name}`
      })));
      setExamples(workSpaceExamples, "workspaceId");

      const docExamples = orgs.flatMap(({org, workspaces}) => workspaces.flatMap(ws => ws.docs.map(doc => ({
        value: doc.id,
        summary: `${org.name} » ${ws.name} » ${doc.name}`
      }))));
      setExamples(docExamples, "docId");
    }).catch(reportError);
  }
  return onComplete;
}

async function requestInterceptor(request: SwaggerUI.Request) {
  delete request.headers.Authorization;
  const url = new URL(request.url);
  // Swagger will use this request interceptor for several kinds of
  // requests, such as requesting the API YAML spec from Github:
  //
  //      Function to intercept remote definition, "Try it out",
  //      and OAuth 2.0 requests.
  //
  //    https://swagger.io/docs/open-source-tools/swagger-ui/usage/configuration/
  //
  // We want to ensure that only "Try it out" requests have XHR, so
  // that they pass a same origin request, even if they're not GET,
  // HEAD, or OPTIONS. "Try it out" requests are the requests to the
  // same origin.
  if (url.origin === window.origin) {
    // Without this header, unauthenticated multipart POST requests
    // (i.e. file uploads) would fail in the API console. We want those
    // requests to succeed.
    request.headers['X-Requested-With'] = 'XMLHttpRequest';
    if (request.method.toLowerCase() === 'delete') {
      const text = await invokePrompt(
        t('Confirm Deletion'), {
          btnText: t('Delete'),
          placeholder: t('Type DELETE here if you wish to proceed.'),
          body: [
            dom(
              'p',
              t('Are you sure you want to delete the following?')
            ),
            dom(
              'p',
              dom('tt', url.pathname)
            ),
            dom(
              'p',
              t(`Type DELETE if you are sure you do indeed wish to do this deletion.
If you are not sure, or do not understand what this operation will do,
it would be wise to cancel it.`)
            )
          ]
        });
      if (text !== 'DELETE') {
        reportError(t('Deletion was not confirmed, skipping.'));
        throw new Error('Deletion was not confirmed');
      }
    }
  }
  return request;
}

createAppPage((appModel) => {
  // Default Grist page prevents scrolling unnecessarily.
  document.documentElement.style.overflow = 'initial';

  const rootNode = cssWrapper();
  const onComplete = initialize(appModel);

  externalScriptsPromise.then(() => {
    const buildSwaggerUI: typeof SwaggerUI = (window as any).SwaggerUIBundle;
    swaggerUI = buildSwaggerUI({
      filter: true,
      plugins: [gristPlugin.bind(null, appModel)],
      url: 'https://raw.githubusercontent.com/gristlabs/grist-help/master/api/grist.yml',
      domNode: rootNode,
      showMutatedRequest: false,
      requestInterceptor,
      onComplete,
    });
  })
  .catch(reportError);

  return rootNode;
});

const cssWrapper = styled('div', `
  & .scheme-container {
    display: none;
  }
  & .information-container h1 {   /* Authorization header, strangely enough */
    display: none;
  }
  & .information-container .info {
    margin-bottom: 0;
  }
`);
